/////////////////////////////////////////////////////////////////////////////////////////////////////
// Copyright (c) 2009 Eric Bowen
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and 
// associated documentation files (the "Software"), to deal in the Software without restriction, including 
// without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 
// copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the 
// following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial 
// portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 
// LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN 
// NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
/////////////////////////////////////////////////////////////////////////////////////////////////////
// Author's blog: http://blog.scrappydog.com
// Updated source code available:  http://scrappydb.codeplex.com
/////////////////////////////////////////////////////////////////////////////////////////////////////

using System;
using System.Collections;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Reflection;
using System.Web.Caching;
using Amazon.SimpleDB.Model;
using ScrappyDB.Attributes;
using ScrappyDB.BaseClasses;
using ScrappyDB.Utilities;
using Attribute = Amazon.SimpleDB.Model.Attribute;

namespace ScrappyDB
{
    public partial class Db
    {

        #region "Basic Find overloads with ConsistentRead defaulting to TRUE"

        public T Find<T>(object id) where T : class, new()
        {
            string encodedId = EntityHelper.EncodeItemNameValue<T>(id);
            return FindInternal<T>(encodedId, true);
        }



        //public T Find<T>(Guid id) where T : class, new()
        //{
        //    return Find<T>(id.ToString(), true);
        //}

        //public T Find<T>(DateTime id) where T : class, new()
        //{
        //    return Find<T>(DbDateTime.ConvertDateTimeToIso8601(id), true);
        //}

        //public T Find<T>(int id) where T : class, new()
        //{
        //    //TODO: deal with RawNumberAttribute id's

        //    var encodedId = Utility.EncodeNumeric(id);
        //    return Find<T>(encodedId, true);
        //}

        //public T Find<T>(short id) where T : class, new()
        //{
        //    //TODO: deal with RawNumberAttribute id's

        //    var encodedId = Utility.EncodeNumeric(id);
        //    return Find<T>(encodedId, true);
        //}

        //public T Find<T>(string id) where T : class, new()
        //{
        //    return Find<T>(id, true);
        //}

        #endregion

        #region "Find overloads with ConsistentRead parameter"

        public T Find<T>(object id, bool consistentRead) where T : class, new()
        {
            string encodedId = EntityHelper.EncodeItemNameValue<T>(id);
            return FindInternal<T>(encodedId, consistentRead);
        }

        ///// <summary>
        ///// Returns 1 record based on the supplied Id (ItemName) value
        ///// </summary>
        ///// <typeparam name="T"></typeparam>
        ///// <param name="id">Guid</param>
        ///// <param name="consistentRead"></param>
        ///// <returns></returns>
        //public T Find<T>(Guid id, bool consistentRead) where T : class, new()
        //{
        //    return Find<T>(id.ToString(), consistentRead);
        //}

        ///// <summary>
        ///// Returns 1 record based on the supplied Id (ItemName) value
        ///// </summary>
        ///// <typeparam name="T"></typeparam>
        ///// <param name="id">DateTime</param>
        ///// <param name="consistentRead"></param>
        ///// <returns></returns>
        //public T Find<T>(DateTime id, bool consistentRead) where T : class, new()
        //{
        //    return Find<T>(DbDateTime.ConvertDateTimeToIso8601(id), consistentRead);
        //}

        ///// <summary>
        ///// Returns 1 record based on the supplied Id (ItemName) value
        ///// </summary>
        ///// <typeparam name="T"></typeparam>
        ///// <param name="id"></param>
        ///// <param name="consistentRead"></param>
        ///// <returns></returns>
        //public T Find<T>(int id, bool consistentRead) where T : class, new()
        //{
        //    return Find<T>(id.ToString(), consistentRead);
        //}

        #endregion

        /// <summary>
        /// Returns 1 record based on the supplied Id (ItemName) value
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="id"></param>
        /// <param name="consistentRead"></param>
        /// <returns></returns>
        private T FindInternal<T>(string id, bool consistentRead) where T : class, new()
        {
            //caching will break consistent reads so we need to disable it
            if (consistentRead)
                WebCache = null;

            T result = null;

            //This timeout logic is intended to deal with a backoff in the event of system errors
            int retryTimeout = 100; //delay for first retry
            int maxRetry = 0;  //no retry by default 

            do
            {
                try
                {
                    result = LoadInternal<T>(id, consistentRead);
                }
                catch (Exception ex)
                {
                    if (!ex.Message.Contains("50"))
                        throw;

                    //we are expecting possible 500's and 503's here
                    Log.Error("Find<T> returned a system error:{0}", ex.Message);

                    //bump up the timeout in the event of system errors
                    maxRetry = 10000;
                }

                retryTimeout = retryTimeout * 2;

                if (result == null & retryTimeout < maxRetry)
                {
                    Log.Warn("Find<T> returned null, were are going to retry in {0} milliseconds", retryTimeout);
                    System.Threading.Thread.Sleep(retryTimeout);
                }
            } while (result == null & retryTimeout < maxRetry);

            if (result == null)
                Log.Warn("Find<T> returned null");

            return result;
        }

        /// <summary>
        /// Returns 1 record based on the supplied Id (ItemName) value
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="id"></param>
        /// <param name="consistentRead"></param>
        /// <returns></returns>
        private T LoadInternal<T>(string id, bool consistentRead) where T : class, new()
        {
            Log.Debug("DB.Find Start: " + DateTime.Now.ToLongTimeString());

            if (string.IsNullOrEmpty(id))
                throw new ArgumentNullException("id", "Id cannot be null or empty");

            var o = new T();
            if (this.WebCache != null)
            {
                if (typeof(T).IsSubclassOf(typeof(SdbEntity)))
                {
                    var webCacheProperty = typeof(T).GetProperty("WebCache");
                    webCacheProperty.SetValue(o, WebCache, null);
                }
            }

            string domainName = EntityHelper.GetDomainName(o);
            var domain = new Domains(WebCache);
            domain.EnsureDomain(domainName);

            if (WebCache != null)
            {
                var c = (T)WebCache.Get(EntityCacheKey(domainName, id));
                if (c != null)
                {
                    Log.Debug("CACHE HIT: ScrappyDB.Find<T>: Domain: {0} - Entity: {1} - Id: {2} ",
                                           domainName,
                                           EntityHelper.GetEntityName(o), id);

                    //pass the cache goodness on to the new object
                    if (typeof(T).IsSubclassOf(typeof(SdbEntity)))
                    {
                        var webCacheProperty = typeof(T).GetProperty("WebCache");
                        webCacheProperty.SetValue(c, WebCache, null);
                    }

                    return c;
                }
            }

            EntityHelper.LoadItemNameProperty(o, id);

            var request = new GetAttributesRequest
                              {
                                  DomainName = domainName,
                                  ItemName = id,
                                  ConsistentRead = consistentRead
                              };
            GetAttributesResponse response = Service.GetAttributes(request);

            Log.Info("ScrappyDB.Find<T>: Domain: {0} - Entity: {1} - Id: {2} ", domainName,
                                   EntityHelper.GetEntityName(o), id);
            if (response.IsSetResponseMetadata())
                Utility.TraceResponse(response.ResponseMetadata, response.ToXML());

            if (response.IsSetGetAttributesResult())
            {
                if (response.GetAttributesResult.Attribute.Count == 0)
                {
                    //no result found
                    return null;
                }

                //we got back data... lets populate the object
                GetAttributesResult getAttributesResult = response.GetAttributesResult;
                List<Attribute> attributeList = getAttributesResult.Attribute;

                LoadProperties(o, attributeList);
                LoadChildObjects(o, id, consistentRead);

                if (WebCache != null)
                {
                    WebCache.Add(EntityCacheKey(domainName, id), o, null,
                        Configuration.CacheTimeout,
                        Cache.NoSlidingExpiration,
                        CacheItemPriority.Default, null);

                    Log.Debug("CACHE ADD: key - {0}", EntityCacheKey(domainName, id));
                }
            }

            //pass the cache goodness on to the new object
            if (typeof(T).IsSubclassOf(typeof(SdbEntity)))
            {
                var webCacheProperty = typeof(T).GetProperty("WebCache");
                webCacheProperty.SetValue(o, WebCache, null);
            }
            return o;
        }

        /// <summary>
        /// Returns a collection of ScrappyDbEntities based on a collection of Id's
        /// 
        /// Note: ConsistentRead defaults to FALSE because this method returns multiple values
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="id"></param>
        /// <returns></returns>
        public SdbCollection<T> Find<T>(params object[] id) where T : class, new()
        {
            return Find<T>(true, id);
        }

        /// <summary>
        /// Returns a collection of records based on the supplied List of Id's (ItemName's) value
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="id"></param>
        /// <param name="consistentRead"></param>
        /// <returns></returns>
        public SdbCollection<T> Find<T>(bool consistentRead, params object[] id) where T : class, new()
        {
            //caching will break consistent reads so we need to disable it
            if (consistentRead)
                WebCache = null;

            SdbCollection<T> result = null;

            //This timeout logic is intended to deal with a backoff in the event of system errors
            int retryTimeout = 100; //delay for first retry
            int maxRetry = 0;  //no retry by default 

            do
            {
                try
                {
                    result = LoadInternal<T>(consistentRead, id);
                }
                catch (Exception ex)
                {
                    if (!ex.Message.Contains("50"))
                        throw;

                    //we are expecting possible 500's and 503's here
                    Log.Error("Find<T> returned a system error:{0}", ex.Message);

                    //bump up the timeout in the event of system errors
                    maxRetry = 10000;
                }
                retryTimeout = retryTimeout * 2;

                if (result == null & retryTimeout < maxRetry)
                {
                    Log.Warn("Find<T> returned null, were are going to retry in {0} milliseconds", retryTimeout);
                    System.Threading.Thread.Sleep(retryTimeout);
                }
            } while (result == null & retryTimeout < maxRetry);

            if (result == null)
                Log.Warn("Find<T> returned null");

            return result;
        }

        private SdbCollection<T> LoadInternal<T>(bool consistentRead, params object[] ids) where T : class, new()
        {
            var o = new T();
            int predicateLimit = 20;

            //there is a 20 element Amazon predicate limit, but 1 of them will be used by EntityName if needed
            if (!(o.GetType().GetCustomAttributes(typeof(NoEntityNameFieldAttribute), true).Length > 0))
                predicateLimit = 19;


            if (ids == null || ids.Length == 0)
                throw new ArgumentNullException("ids", "Id cannot be null or empty");

            var result = new SdbCollection<T>();

            if (ids.Length <= predicateLimit)
                return LoadSubSet<T>(consistentRead, ids);

            int counter = 0;
            var idSubSet = new object[predicateLimit];
            foreach (object id in ids)
            {
                idSubSet[counter] = id;
                counter++;
                if (counter == predicateLimit)
                {
                    //go get a set of X (predicateLimit) records 
                    ProcessSubset(result, consistentRead, idSubSet);
                    counter = 0;
                    idSubSet = new object[predicateLimit];
                }
            }

            //if there were less than predicateLimit id's we still need to process them
            if (idSubSet.Length > 0)
            {
                ProcessSubset(result, consistentRead, idSubSet);
            }

            return result;
        }

        private void ProcessSubset<T>(SdbCollection<T> result, bool consistentRead, params object[] id) where T : class, new()
        {
            var subset = LoadSubSet<T>(consistentRead, id);
            foreach (T o in subset)
            {
                result.Add(o);
            }
        }

        private SdbCollection<T> LoadSubSet<T>(bool consistentRead, params object[] id) where T : class, new()
        {
            Log.Debug("DB.Find Start: " + DateTime.Now.ToLongTimeString());

            if (id == null || id.Length == 0)
                throw new ArgumentNullException("id", "Id cannot be null or empty");

            var o = new T();
            if (this.WebCache != null)
            {
                if (typeof(T).IsSubclassOf(typeof(SdbEntity)))
                {
                    var webCacheProperty = typeof(T).GetProperty("WebCache");
                    webCacheProperty.SetValue(o, WebCache, null);
                }
            }

            string domainName = EntityHelper.GetDomainName(o);
            var domain = new Domains(WebCache);
            domain.EnsureDomain(domainName);

            var result = new SdbCollection<T>();
            var request = new SelectRequest()
            {
                ConsistentRead = consistentRead
            };

            //there has to be at least one value... so let's use the first one.
            //EntityHelper.LoadItemNameProperty(o, id[0]);

            request.SelectExpression = "select * from " + domainName + " ";

            string entityName = null;
            if (!(o.GetType().GetCustomAttributes(typeof(NoEntityNameFieldAttribute), true).Length > 0))
            {
                entityName = EntityHelper.GetEntityName(o);
                if (!string.IsNullOrEmpty(entityName))
                    request.SelectExpression += " where EntityName = '" + entityName + "' ";
            }

            if (string.IsNullOrEmpty(entityName))
            {
                request.SelectExpression += " where ";
            }
            else
            {
                request.SelectExpression += " and ";
            }


            var itemNamePropertyName = EntityHelper.GetItemNamePropertyName(o);
            int itemCount = 0;

            request.SelectExpression += string.Format(" ItemName() in(", itemNamePropertyName);

            foreach (var i in id)
            {
                request.SelectExpression += string.Format("'{0}'", i);
                itemCount++;
                if (itemCount < id.Length)
                {
                    request.SelectExpression += ",";
                }
            }

            request.SelectExpression += ") ";

            string cacheBase = Utility.GetMd5Sum(request.SelectExpression);
            string cacheKey = cacheBase;

            if (WebCache != null)
            {
                //this "should" be unique...
                var c = (SdbCollection<T>)WebCache.Get(cacheKey);
                if (c != null)
                {
                    Log.Info("CACHE HIT: ScrappyDB.Query<T>: Domain: {0} - Entity: {1} ", domainName,
                                           EntityHelper.GetEntityName(o));
                    Log.Info("CACHE HIT: " + request.SelectExpression);

                    //pass the cache goodness on...
                    c.WebCache = WebCache;

                    return c;
                }
            }

            Log.Info("ScrappyDB.Query<T>: Domain: {0} - Entity: {1} ", domainName,
                                   EntityHelper.GetEntityName(o));
            Log.Info(request.SelectExpression);


            SelectResponse response = Service.Select(request);

            if (response.IsSetResponseMetadata())
                Utility.TraceResponse(response.ResponseMetadata, response.ToXML());

            if (response.SelectResult.IsSetNextToken())
                result.NextToken = response.SelectResult.NextToken;

            if (response.IsSetSelectResult())
            {
                SelectResult selectResult = response.SelectResult;
                List<Item> itemList = selectResult.Item;
                foreach (Item item in itemList)
                {
                    var p = Find<T>(item, consistentRead);
                    result.Add(p);
                }

                Log.Info("{0} Rows Returned", itemList.Count.ToString());

                if (WebCache != null)
                {
                    WebCache.Add(cacheKey, result, null, Configuration.CacheTimeout,
                        Cache.NoSlidingExpiration,
                        CacheItemPriority.Default, null);

                    Log.Info("CACHE ADD: key - {0}", cacheKey);
                }
            }

            //pass the cache goodness on...
            result.WebCache = WebCache;

            return result;
        }

        private T Find<T>(Item item, bool consistentRead) where T : class, new()
        {
            var o = new T();
            if (this.WebCache != null)
            {
                if (typeof(T).IsSubclassOf(typeof(SdbEntity)))
                {
                    var webCacheProperty = typeof(T).GetProperty("WebCache");
                    webCacheProperty.SetValue(o, WebCache, null);
                }
            }

            if (item.IsSetName())
            {
                EntityHelper.LoadItemNameProperty(o, item.Name);

                //Log.Info("Name = " + item.Name);

                LoadProperties(o, item.Attribute);
                LoadChildObjects(o, item.Name, consistentRead);
            }
            else
            {
                Log.Error("ERROR: DBLoad.Find<>: IsSetName = false");
                throw new Exception("I don't know why this would ever happen...");
            }

            return o;
        }

        private void LoadProperties<T>(T o, IEnumerable<Attribute> attributeList) where T : class, new()
        {
            string entityName = EntityHelper.GetEntityName(o);
            Dictionary<string, string> alternatePropertyNames = null;
            bool alternatePropertyNamesLoaded = false;

            foreach (Attribute attribute in attributeList)
            {
                if (attribute.IsSetName())
                {
                    if (attribute.Name == "EntityName")
                    {
                        //consistency check                   
                        if (entityName != attribute.Value)
                        {
                            //I don't think this should ever happen... but I don't think it needs an exception either
                            Log.Error("Data error EntityTypes don't match. Data: {0} vs.  Class: {1}",
                                             attribute.Name, entityName);
                        }
                    }
                    else
                    {
                        PropertyInfo prop = o.GetType().GetProperty(attribute.Name);

                        if (prop == null)
                        {
                            //load alternate propertynames dictionary
                            if (!alternatePropertyNamesLoaded)
                            {
                                foreach (PropertyInfo propertyInfo in o.GetType().GetProperties())
                                {
                                    var alternateAttributes =
                                        propertyInfo.GetCustomAttributes(typeof(AlternatePropertyNameAttribute), true);
                                    if (alternateAttributes.Length > 0)
                                    {
                                        foreach (
                                            AlternatePropertyNameAttribute alternateAttribute in alternateAttributes)
                                        {
                                            if (alternatePropertyNames == null)
                                                alternatePropertyNames = new Dictionary<string, string>();

                                            alternatePropertyNames.Add(alternateAttribute.AlternatePropertyName,
                                                                       propertyInfo.Name);

                                            alternatePropertyNamesLoaded = true;
                                        }
                                    }
                                }
                            }
                        }

                        //check for alternate propertynames
                        if (alternatePropertyNames != null)
                        {
                            var alternateName = string.Empty;
                            if (alternatePropertyNames.TryGetValue(attribute.Name, out alternateName))
                            {
                                prop = o.GetType().GetProperty(alternateName);
                            }
                        }


                        if (prop == null)
                        {
                            Log.Warn(
                                    "SimpleDB data attribute '{0}' with value '{2}' doesn't exist as a property in entity '{1}'",
                                    attribute.Name, entityName, attribute.Value);
                            continue;

                        }

                        if (attribute.IsSetValue() && prop.CanWrite && !string.IsNullOrEmpty(attribute.Value))
                        {
                            //Log.Info(attribute.Name + " = " + attribute.Value);
                            if (prop.PropertyType == typeof(Guid))
                                prop.SetValue(o, new Guid(attribute.Value), null);

                            if (prop.PropertyType == typeof(string))
                                prop.SetValue(o, attribute.Value, null);

                            //boolean values
                            if (prop.PropertyType == typeof(bool))
                            {
                                if (prop.GetCustomAttributes(typeof(OneZeroBoolAttribute), true).Length > 0)
                                {
                                    //boolean should have been stored as a one or a zero
                                    var rawBool = attribute.Value;
                                    if (rawBool == "1")
                                        prop.SetValue(o, true, null);
                                    if (rawBool == "0")
                                        prop.SetValue(o, false, null);
                                }
                                else
                                {
                                    //boolean should have been stored as "True" or "False"
                                    prop.SetValue(o, bool.Parse(attribute.Value), null);
                                }
                            }

                            if (prop.PropertyType == typeof(Int16))
                                prop.SetValue(o, Utility.DecodeNumericInt16(attribute.Value), null);

                            if (prop.PropertyType == typeof(Int32))
                                prop.SetValue(o, Utility.DecodeNumericInt32(attribute.Value), null);

                            if (prop.PropertyType == typeof(Int64))
                                throw new NotImplementedException();

                            if (prop.PropertyType == typeof(ushort))
                                prop.SetValue(o, uint.Parse(attribute.Value), null);

                            if (prop.PropertyType == typeof(uint))
                                prop.SetValue(o, uint.Parse(attribute.Value), null);

                            if (prop.PropertyType == typeof(ulong))
                                prop.SetValue(o, ulong.Parse(attribute.Value), null);

                            //Numeric values
                            if (prop.PropertyType == typeof(double))
                            {
                                if (prop.GetCustomAttributes(typeof(RawNumberAttribute), true).Length > 0)
                                {
                                    //the stored value should be in a raw (unzeropadded and unoffset) format
                                    prop.SetValue(o, double.Parse(attribute.Value), null);
                                }
                                else
                                {
                                    //the stored value should be zero padded and offset)
                                    prop.SetValue(o, Utility.DecodeNumeric(attribute.Value), null);
                                }
                            }

                            //DateTime values
                            if (prop.PropertyType == typeof(DateTime))
                                prop.SetValue(o, DbDateTime.ConvertIso8601ToDateTime(attribute.Value), null);

                            //List<T>
                            if (prop.PropertyType.Name == "List`1")
                            {
                                //HACK: if the value is an empty string we are going to assume it is null
                                if (!string.IsNullOrEmpty(attribute.Value))
                                {
                                    //what is T?
                                    //we are going to assume this is a List<string> for now...
                                    var list = (List<string>)prop.GetValue(o, null);

                                    if (list == null)
                                        list = new List<string>();

                                    if (!list.Contains(attribute.Value))
                                        list.Add(attribute.Value);

                                    prop.SetValue(o, list, null);
                                }
                            }

                            if (prop.PropertyType.BaseType != null && prop.PropertyType.BaseType.FullName == "System.Array")
                            {
                                if (prop.PropertyType.HasElementType)
                                {
                                    ArrayList newArrayList;

                                    Type t = prop.PropertyType.GetElementType();
                                    TypeCode typeCode = Type.GetTypeCode(t);

                                    switch (typeCode)
                                    {
                                        case TypeCode.Int32:
                                            newArrayList = PopulateNewArrayList(attribute, o);

                                            //save the updated array
                                            var newArray = new int[newArrayList.Count];
                                            newArrayList.CopyTo(newArray);

                                            prop.SetValue(o, newArray, null);

                                            break;
                                        default:
                                            throw new Exception("Type not implemented yet");
                                    }
                                }
                            }
                        }

                    }
                }
            }
        }

        private void LoadChildObjects<T>(T o, string id, bool consistentRead)
        {
            foreach (PropertyInfo prop in o.GetType().GetProperties())
            {
                if (prop.GetCustomAttributes(typeof(DoNotPersistAttribute), true).Length > 0)
                    continue;

                Type t = prop.PropertyType;
                //TODO: Need to figure out how to do this type checking correctly...
                if (t.IsGenericType && t.GetGenericArguments()[0].Name != "String")
                {
                    //create an instance of the collection
                    object z = Activator.CreateInstance(t, null);

                    Collection<Relationship> c = Query<Relationship>(" ParentId = '" + id + "' and ChildEntityType = '" + t.GetGenericArguments()[0].Name + "' ", consistentRead);
                    foreach (Relationship i in c)
                    {
                        //TODO: long term we need batch these up instead of calling them one at a time...

                        //call the AddById method to add each record
                        t.InvokeMember("AddById",
                                       BindingFlags.InvokeMethod | BindingFlags.Instance | BindingFlags.Public,
                                       null, z, new object[] { i.ChildId });
                    }

                    prop.SetValue(o, z, null);
                }
            }
        }

        private ArrayList PopulateNewArrayList<T>(Attribute attribute, T o)
        {
            var result = new ArrayList();
            PropertyInfo prop = o.GetType().GetProperty(attribute.Name);

            var existing = (Array)prop.GetValue(o, null);

            //create an arraylist of the existing records (if any)
            if (existing != null)
            {
                foreach (object i in existing)
                {
                    result.Add(i);
                }
            }

            //add the new value
            result.Add(int.Parse(attribute.Value));

            return result;
        }
    }
}